21 · Platform Channel 原生桥接
目标:Flutter 调不了的能力(厂商 SDK、系统 API、老原生模块),用 Platform Channel 打通——能写、能调、能排错,并知道什么时候不该用它。
⚠️ 本篇是完全空白的能力补缺:现有 00~20 篇均未涉及原生交互。
开篇:前端概念对照
| 前端 | Flutter |
|---|---|
| JS 调原生能力靠浏览器/Web API 封装 | 靠你自己写 Platform Channel |
postMessage / JSBridge(Hybrid) | MethodChannel 的方法调用 |
| 浏览器事件推送(如 websocket 帧) | EventChannel 的持续流 |
| Cordova/Capacitor 插件 | Flutter Plugin(pub.dev 上大多就是 Channel 封装) |
| JS ↔ Native 序列化(JSON) | StandardMessageCodec(自动类型转换) |
心智模型:前端调原生是"浏览器已经替你封装好了";Flutter 只封装了渲染,其余系统能力要么用插件(本质是别人写好的 Channel),要么自己写 Channel。这是 Hybrid 转 Flutter 最需要补的认知。
一、先判断:真的需要写 Channel 吗
pub.dev 上有维护良好的插件? ──是─▶ 用插件(不要重复造轮子)
↓ 否
插件存在但不维护/不满足需求? ──是─▶ fork 或参考其源码自己写
↓ 否
是自研/厂商私有 SDK? ──是─▶ 自己写 Channel能用插件就别自己写——插件已处理了版本兼容、生命周期、权限、线程等一大堆坑。本篇的价值在于:插件满足不了时你知道怎么造,插件出问题时你能看懂源码。
二、MethodChannel:一次性调用(最常用)
场景:Flutter 调原生方法拿返回值,如"获取设备型号""调起厂商 SDK 初始化"。
2.1 Dart 侧
// lib/core/channel/native_bridge.dart
import 'package:flutter/services.dart';
class NativeBridge {
// channel 名必须两端完全一致,建议加包名前缀避免冲突
static const _channel = MethodChannel('com.haier.energy/native');
static Future<String> getDeviceModel() async {
try {
final String model = await _channel.invokeMethod('getDeviceModel');
return model;
} on PlatformException catch (e) {
// 原生抛的错都在这里,code/message/details 三件套
throw NativeException(e.code, e.message ?? '', e.details);
} on MissingPluginException {
// 典型:该平台未实现 / 热重载后原生代码未生效(需冷启动)
throw NativeException('NOT_IMPLEMENTED', '该平台未实现 getDeviceModel');
}
}
static Future<bool> initVendorSdk(Map<String, dynamic> config) async {
final ok = await _channel.invokeMethod<bool>('initVendorSdk', config);
return ok ?? false;
}
}2.2 Android 侧(Kotlin)
// MainActivity.kt
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.haier.energy/native"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getDeviceModel" -> result.success(Build.MODEL)
"initVendorSdk" -> {
val config = call.arguments as? Map<*, *>
try {
VendorSdk.init(this, config)
result.success(true)
} catch (e: Exception) {
// code / message / details 对应 Dart 的 PlatformException
result.error("INIT_FAILED", e.message, null)
}
}
else -> result.notImplemented() // 触发 MissingPluginException
}
}
}
}2.3 iOS 侧(Swift)
// AppDelegate.swift
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
name: "com.haier.energy/native",
binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { call, result in
switch call.method {
case "getDeviceModel":
result(UIDevice.current.model)
case "initVendorSdk":
guard let args = call.arguments as? [String: Any] else {
result(FlutterError(code: "BAD_ARGS", message: "参数错误", details: nil))
return
}
VendorSdk.shared.init(config: args)
result(true)
default:
result(FlutterMethodNotImplemented)
}
}2.4 支持的数据类型(不用手写序列化)
| Dart | Android | iOS |
|---|---|---|
null | null | nil |
bool | Boolean | NSNumber(bool:) |
int | Integer | NSNumber(int:) |
double | Double | NSNumber(double:) |
String | String | String |
Uint8List | byte[] | FlutterStandardTypedData |
List | List | NSArray |
Map | Map | NSDictionary |
⚠️ 不支持自定义对象——复杂对象传 Map 或 JSON 字符串(用 Freezed + toJson,见 09 篇)。
2.5 本项目实例:haier.energy/arms
本项目只有一条自定义 Channel(其余原生能力一律走插件或 lib/core/platform/ 抽象),就是启动阿里云 ARMS Native SDK。它是理解"为什么非要 Channel 不可"的最佳样本:
为什么不能用插件解决:ARMS 有 Flutter 插件(alibabacloud_rum_flutter_plugin),但插件只能管 Flutter 侧的监控,Native 崩溃与启动性能必须在 Application/AppDelegate 阶段就启动,而这个时点 Flutter 引擎还没起来。又因合规要求必须等用户同意隐私协议后才能初始化,所以不能在原生 onCreate 里无条件启动——最终方案是:原生注册 Channel 待命,由 Flutter 侧在隐私同意后主动调用 start。
Dart 侧 lib/core/analytics/arms_analytics.dart:33-50:
static const _nativeChannel = MethodChannel('haier.energy/arms');
static Future<void> initialize() async {
if (_initialized) return;
if (!kReleaseMode && !enableInDebugMode) return; // 模式开关集中在此
try {
await _nativeChannel.invokeMethod<void>('start',
{'env': RuntimeEnvironment.current.name}); // 环境名传给原生
} catch (e) {
AppLogger.I.w('[ARMS] native start failed: $e'); // 原生失败不阻断
}
try {
await AlibabaCloudRUM().initialize(); // 再初始化 Flutter 插件
await AlibabaCloudRUM().setExtraInfo({
'environment': RuntimeEnvironment.current.name,
});
Logger().logLevel = kReleaseMode ? LogLevel.Error : LogLevel.Debug;
_initialized = true;
} catch (e) {
AppLogger.I.w('[ARMS] initialize failed: $e');
}
}Android 侧 android/app/src/main/kotlin/com/haier/energy/platform/MainActivity.kt:
private companion object { const val CHANNEL = "haier.energy/arms" }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "start") {
startArms(call.argument<String>("env"))
result.success(null)
} else {
result.notImplemented() // 未知方法必须回调,否则 Dart 侧一直挂
}
}
}
private fun startArms(envName: String?) {
val env = when (envName) {
"dev" -> Env.LOCAL; "test" -> Env.DAILY; "prod" -> Env.PROD
else -> Env.PROD // 兜底按生产,避免 dev 数据污染
}
AlibabaCloudRum.withServiceId(BuildConfig.ARMS_SERVICE_ID)
.withWorkspace(BuildConfig.ARMS_WORKSPACE)
.withEndpoint(BuildConfig.ARMS_ENDPOINT)
.withEnvironment(env)
.start(applicationContext)
}iOS 侧 ios/Runner/AppDelegate.swift:15-32:
// ARMS Native 启动改为由 Flutter 侧在用户同意隐私协议后通过 Method Channel
// 触发(对齐阿里云合规延迟初始化)。
if let controller = window?.rootViewController as? FlutterViewController {
FlutterMethodChannel(name: armsChannel, binaryMessenger: controller.binaryMessenger)
.setMethodCallHandler { [weak self] call, result in
if call.method == "start" {
let args = call.arguments as? [String: Any]
self?.startArms(envName: args?["env"] as? String)
result(nil)
} else {
result(FlutterMethodNotImplemented)
}
}
}这个案例里值得抄的四个做法:
| 做法 | 具体体现 | 为什么重要 |
|---|---|---|
| Channel 名常量化 | Dart / Kotlin / Swift 三处同名字符串,Kotlin 用 companion object 常量 | 拼错一个字母就是 MissingPluginException,常量能减少出错面 |
| 未知方法必须回调 | 两端都写了 notImplemented() / FlutterMethodNotImplemented | 不回调,Dart 侧 await 永远不返回 |
| 环境由 Flutter 侧下发 | {'env': RuntimeEnvironment.current.name},原生再映射到 Env.LOCAL/DAILY/PROD | 环境真源只有一份(见 environment.dart),避免三端各配一套 |
| 失败不阻断主流程 | Dart 侧两次 try/catch + AppLogger.w | 监控挂了不能让 App 起不来 |
| 敏感配置不进 Channel | ARMS_SERVICE_ID 走 BuildConfig,iOS 走 Info.plist | 密钥不硬编码、不跨端传输 |
顺带一个反例提醒:如果只是为了"拿个设备信息""读个电量",先搜插件—— 本项目 40+ 依赖里绝大多数原生能力都有现成插件,Channel 是最后手段,不是首选。
三、EventChannel:持续事件流
场景:原生持续向 Flutter 推送(定位变化、扫码结果、传感器、下载进度)。
static const _eventChannel = EventChannel('com.haier.energy/location');
Stream<LocationPoint> get locationStream {
return _eventChannel
.receiveBroadcastStream() // 返回 Stream<dynamic>
.map((event) => LocationPoint.fromJson(Map<String, dynamic>.from(event)))
.handleError((e) => debugPrint('定位流异常: $e'));
}Android 侧用 EventChannel.StreamHandler,在 onListen 里注册系统监听、onCancel 里务必反注册(否则内存泄漏,对照 05 篇 dispose 泄漏):
EventChannel(messenger, "com.haier.energy/location").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
listener = { loc -> events?.success(loc.toMap()) }
LocationManager.register(listener) // 注册
}
override fun onCancel(arguments: Any?) {
LocationManager.unregister(listener) // 必须反注册
}
})四、线程模型:最容易出诡异 bug 的地方
铁律:原生侧的方法回调必须在主线程(UI 线程)执行。
MethodChannel(...).setMethodCallHandler { call, result ->
// ⚠️ 这个回调跑在主线程——耗时操作必须切线程
if (call.method == "queryDatabase") {
thread { // 切后台
val data = db.query() // 耗时
runOnUiThread { result.success(data) } // 切回主线程再回调
}
}
}| 平台 | 要求 |
|---|---|
| Android | Handler 在主线程;耗时任务切后台,回调切回主线程 |
| iOS | 回调需切 main queue(DispatchQueue.main.async) |
违反时的典型症状:不崩溃、无日志、结果永远不回来(或偶发成功)。这类问题极难定位,写 Channel 第一时间就把线程规则做对。
五、错误处理与排错清单
| 症状 | 原因 | 排查 |
|---|---|---|
MissingPluginException | ① 该平台未实现 ② 热重载后原生代码未生效 → 必须冷启动(Full Restart) ③ channel 名两端不一致 | 先冷启动;再核对 channel 名字符串 |
| 返回值永远是 null | invokeMethod<T> 泛型与原生返回类型不匹配 | 打印原生侧实际返回类型 |
| 调用后无任何响应 | 回调在非主线程 / 原生侧没调 result.success/error | 检查每个分支都要调 result |
| 类型转换崩溃 | 传了不支持的类型(如自定义对象) | 转 Map/JSON |
| 只在 Release 包出错 | 混淆把通道方法名/类混淆了 | 加混淆保留规则(见 23 篇) |
调试建议:原生侧在入口打日志(Android Log.d / iOS os_log),用 Android Studio / Xcode 控制台看——Dart 侧的断点看不到原生执行过程。
六、封装规范:别让 Channel 散落在 UI 里
与 09 篇数据层链路同款思路——Channel 调用必须包一层 Repository/Service,UI 永远不直接碰:
UI / Notifier
↓ 只依赖抽象
NativeRepository(接口 + 结果封装 Result<T>)
↓ 只有这一层碰
NativeBridge(MethodChannel)abstract class NativeRepository {
Future<Result<String>> getDeviceModel();
}
class NativeRepositoryImpl implements NativeRepository {
@override
Future<Result<String>> getDeviceModel() =>
guard(() => NativeBridge.getDeviceModel()); // guard 见 09 篇
}三个好处:① 可 mock(测试不依赖原生,见 19 篇);② Web/桌面端可提供空实现;③ 原生接口变更只改一处。
测试时打桩
// 测试里直接给通道设 mock handler,不走真实原生
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.haier.energy/native'),
(call) async => call.method == 'getDeviceModel' ? 'Mock-Model' : null,
);七、FFI:另一种与原生通信的方式
| 维度 | Platform Channel | FFI(dart:ffi) |
|---|---|---|
| 通信方式 | 异步消息 + 序列化 | 直接调用 C/C++/Rust 符号,同步 |
| 性能 | 有序列化开销 | 高(适合高频/大数据,如音视频帧、图像处理) |
| 平台代码 | 需写 Kotlin/Swift | 写 C 接口,两端共用 |
| 复杂度 | 低 | 高(内存管理要自己来,泄漏风险大) |
判断:调用频率低、逻辑偏"调 API"→ Channel;高频计算/已有 C/C++ 库 → FFI。绝大多数业务场景用 Channel 就够了。
八、动手练习
练习 21.1(最小打通):实现 getDeviceModel,三端代码写完并跑通真机。故意把 channel 名写错一个字母,观察 MissingPluginException,再改回——亲手建立"名字必须完全一致"的肌肉记忆。
练习 21.2(错误处理):原生侧抛一个自定义错误(如 result.error("PERM_DENIED", "无权限", null)),Dart 侧用 Result.err 转成 UI 可读提示(对照 09 篇错误链路)。
练习 21.3(EventChannel):实现一个简单的"原生定时器每秒推一次时间戳"流,Dart 侧用 StreamBuilder 展示,页面 dispose 后确认原生侧也停止了推送(用原生日志验证 onCancel 被调用)。
练习 21.4(进阶):把练习 21.1 重构成"Repository 抽象 + Bridge 实现"两层,并写单元测试用 setMockMethodCallHandler 打桩(不依赖真机,见 19 篇)。
九、自检清单
- [ ] 能说出先找插件再写 Channel 的判断顺序
- [ ] 能独立写出 MethodChannel 的 Dart + Android + iOS 三端最小实现
- [ ] 知道回调必须在主线程,以及违反时的典型症状
- [ ] 知道
MissingPluginException的三个原因,尤其"热重载对原生代码无效" - [ ] 能把 Channel 封装进 Repository 层并在测试中打桩
- [ ] 能说出本项目唯一自定义 Channel
haier.energy/arms为什么不能改用插件(合规延迟初始化 + Native 崩溃需在引擎启动前接管)
导航:上一章 18 · 本地存储与持久化 | 下一章 24 · 推送通知与深链接